Single Responsibility Principle: A Recipe for Great Code
Maintainable code is something we all desire and there are no shortage of coding principles that promise it. It is not always apparent how tremendously useful these principles are during the early stages of development. Nonetheless, the effort put in to ensure these qualities certainly pay off as the project grows and development continues. In this article, Toptal engineer Adel Fayzrakhmanov discusses how the Single Responsibility Principle is one of the most important aspect in writing good maintainable code.
Maintainable code is something we all desire and there are no shortage of coding principles that promise it. It is not always apparent how tremendously useful these principles are during the early stages of development. Nonetheless, the effort put in to ensure these qualities certainly pay off as the project grows and development continues. In this article, Toptal engineer Adel Fayzrakhmanov discusses how the Single Responsibility Principle is one of the most important aspect in writing good maintainable code.
Adel (MCE) has 15+ years in software development, focused on web technology and quality architecture. PHP and .NET are his forté.
Expertise
Previously At
Regardless of what we consider to be great code, it always requires one simple quality: the code must be maintainable. Proper indentation, neat variable names, 100% test coverage, and so on can only take you so far. Any code which is not maintainable and cannot adapt to changing requirements with relative ease is code just waiting to become obsolete. We may not need to write great code when we are trying to build a prototype, a proof of concept or a minimum viable product, but in all other cases we should always write code that is maintainable. This is something that should be considered a fundamental quality of software engineering and design.

In this article, I will discuss how the Single Responsibility Principle and some techniques that revolve around it can give your code this very quality. Writing great code is an art, but some principles can always help give your development work the direction it needs to head towards to produce robust and maintainable software.
Model Is Everything
Almost every book about some new MVC (MVP, MVVM, or other M**) framework is littered with examples of bad code. These examples try to show what the framework has to offer. But they also end up providing bad advice for beginners. Examples like “let’s say we have this ORM X for our models, templating engine Y for our views and we will have controllers to manage it all” achieve nothing other than humongous controllers.
Although in defense of these books, the examples are meant to demonstrate the ease at which you can get started with their framework. They are not meant to teach software design. But readers following these examples realize, only after years, how counterproductive it is to have monolithic chunks of code in their project.

Models are the heart of your app. If you have models separated from the rest of your application logic, maintenance will be much easier, regardless of how complicated your application becomes. Even for complicated applications, good model implementation can result in extremely expressive code. And to achieve that, start by making sure that your models do only what they are meant to do, and don’t concern themselves with what the app built around it does. Furthermore, it doesn’t concern itself with what the underlying data storage layer is: does your app rely on an SQL database, or does it store everything in text files?
As we continue this article, you will realize how great code is a lot about separation of concern.
Single Responsibility Principle
You probably have heard about SOLID principles: single responsibility, open-closed, liskov substitution, interface segregation and dependency inversion. The first letter, S, represents Single Responsibility Principle (SRP) and its importance cannot be overstated. I would even argue that it is a necessary and sufficient condition for good code. In fact, in any code that is badly written, you can always find a class that has more than one responsibility - form1.cs or index.php containing a few thousand lines of code is not something that rare to come by and all of us probably have seen or done it.
Let’s take a look at an example in C# (ASP.NET MVC and Entity framework). Even if you are not a C# developer, with some OOP experience you will be able to follow along easily.
namespace Shop.Web.Controllers;
public class OrderController(
DataContext context,
ISmtpClient smtp
) : Controller
{
public IActionResult CreateForm()
{
// View data preparations
return View();
}
[HttpPost]
public async Task<IActionResult> Create(
OrderCreateRequest request
)
{
if (!ModelState.IsValid)
{
// View data preparations
return View();
}
var order = new Order();
// Map request to order
context.Orders.Add(order);
// Reserve ordered goods
// ... huge logic here ...
await context.SaveChangesAsync();
// Send email with order details to customer
await smtp.SendAsync(/* ... */);
return RedirectToAction("Index");
}
// ... many more methods like Create here
}
The DataContext is injected through the primary constructor with a scoped lifetime via AddDbContext, so the old-school using (new DataContext()) anti-pattern is already gone. And yet the controller is still doing too much. Modern syntax does not save you from a SRP violation; the structure underneath is what matters.

Notice in the snippet of code above how the controller knows too much about “placing an order”, including but not limited to storing the Order object, sending emails, etc. That is simply too many jobs for a single class. For every little change, the developer needs to change the entire controller’s code. And just in case another Controller also needs to create orders, more often than not, developers will resort to copy-pasting the code. Controllers should only control the overall process, and not actually house every bit of logic of the process.
But today is the day we stop writing these humongous controllers!
Let us first extract all business logic from the controller and move it to a OrderService class:
namespace Shop.Web.Controllers;
public class OrderController(
IOrderService orderService
) : Controller
{
[HttpPost]
public async Task<IActionResult> Create(
OrderCreateRequest request
)
{
if (!ModelState.IsValid)
{
// View data preparations
return View();
}
await orderService.CreateAsync(request);
return RedirectToAction("Index");
}
}
With this done, the controller now only does only what it is intended to do: control the process. It knows only about views, OrderService and OrderRequest classes - the least set of information required for it to do its job, which is managing requests and sending responses.
This way you will rarely change controller code. Other components such as views, request objects and services can still change as they are linked to business requirements, but not controllers.
This is what SRP is about, and there are many techniques for writing code that meets this principle. One example of this is dependency injection (something that is also useful for writing testable code).
Dependency Injection
It is hard to imagine a large project based on Single Responsibility Principle without Dependency Injection. Let us take a look at our OrderService class again:
namespace Shop.Application.Orders;
public class OrderService
{
public async Task CreateAsync(/* ... */)
{
// Create the order (and let's forget about reserving here,
// it's not important for the following examples)
// Send an email to the client with order details
var smtp = new SmtpClient();
// Set Host, UserName, Password, and other parameters
await smtp.SendMailAsync(/* ... */);
}
}
This code works, but isn’t quite ideal. To understand how the create method OrderService class works, they are forced to understand the intricacies of SMTP. And, again, copy-paste is the only way out to replicate this use of SMTP wherever it is needed. But with a little refactoring, that can change:
public class OrderService
{
private SmtpMailer mailer;
public OrderService()
{
this.mailer = new SmtpMailer();
}
public void Create(...)
{
// Creating the order
// Sending an email to client with order details
this.mailer.Send(...);
}
}
public class SmtpMailer
{
public void Send(
string to,
string subject,
string body
)
{
// SMTP stuff will be only here
}
}
Much better already! But, OrderService class still knows a lot about sending email. It needs exactly SmtpMailer class to send email. What if we want to change it in the future? What if we want to print the contents of the email being sent to a special log file instead of actually sending them in our development environment? What if we want to unit test our OrderService class? Let us continue with refactoring by creating an interface IMailer:
public interface IMailer
{
Task SendAsync(
string to,
string subject,
string body
);
}
SmtpMailer will implement this interface. Also, our application will use an IoC-container and we can configure it so that IMailer is implemented by SmtpMailer class. OrderService can then be changed as follows:
namespace Shop.Application.Orders;
public sealed class OrderService(
IOrderRepository repository,
IMailer mailer
) : IOrderService
{
public async Task CreateAsync(OrderCreateRequest request)
{
var order = new Order();
// Fill the Order entity using the full power of our
// business logic (discounts, promotions, etc.)
await repository.SaveAsync(order);
await mailer.SendAsync(
/* user email, subject, body with order details */
);
}
}
Now we are getting somewhere! I took this chance to also make another change. The OrderService now relies on IOrderRepository interface to interact with the component that stores all our orders. It no longer cares about how that interface is implemented and what storage technology is powering it. Now OrderService class has only code that deals with order business logic.
This way, if a tester were to find something behaving incorrectly with sending emails, developer knows exactly where to look: SmtpMailer class. If something was wrong with discounts, developer, again, knows where to look: OrderService (or in case you have embraced SRP by heart, then it may be DiscountService) class code.
Event Driven Architecture
However, I still don’t like the OrderService.Create method:
public async Task CreateAsync(OrderCreateRequest request)
{
var order = new Order();
// ...
await repository.SaveAsync(order);
await mailer.SendAsync(
/* user email, subject, body */
);
}
Sending an email isn’t quite a part of the main order creation flow. Even if the app fails to send the email, the order is still created correctly. Also, imagine a situation where you have to add a new option in the user settings area that allows them to opt-out from receiving an email after placing an order successfully. To incorporate this into our OrderService class, we will need to introduce a dependency, IUserParametersService. Add localization into the mix, and you have yet another dependency, ITranslator (to produce correct email messages in the user’s language of choice). Several of these actions are unnecessary, especially the idea of adding these many dependencies and ending up with a constructor that does not fit on the screen. I found a great example of this in Magento’s codebase (a popular e-commerce CMS written in PHP) in a class that has 32 dependencies!

Sometimes it is just hard to figure out how to separate this logic, and Magento’s class is probably a victim of one of those cases. That is why I like the event-driven way:
namespace Shop.Domain.Events;
public sealed record OrderCreated(Order Order);
Whenever an order is created, instead of sending an email directly from the OrderService class, special event class OrderCreated is created and an event is generated. Somewhere in the application event handlers will be configured. One of them will send an email to the client.
namespace Shop.Application.EventHandlers;
public sealed class OrderCreatedEmailSender(
IMailer mailer,
IUserParametersService userParameters,
ITranslator translator
) : IEventHandler<OrderCreated>
{
public async Task HandleAsync(
OrderCreated @event,
CancellationToken cancellationToken
)
{
// This class depends on everything it needs to send
// an email.
await mailer.SendAsync(/* ... */);
}
}
The OrderCreated record is plain and serializable by default. We can handle it immediately, in-process, or publish it to a durable broker and process it in a worker that runs separately from the one handling web requests. The choice of broker is largely a deployment decision: managed cloud services (Azure Service Bus, AWS SNS/SQS, Google Pub/Sub), self-hosted brokers (Kafka, RabbitMQ, NATS), or an in-memory bus for tests. In this article the author explains in detail what event-driven architecture is (please pay no attention to the business logic within the OrderController).
Some may argue that it is now difficult to understand what is going on when you create the order. But that cannot be any further from the truth. If you feel that way, simply take advantage of your IDE’s functionality. By finding all the usages of OrderCreated class in the IDE, we can see all the actions associated with the event.
But when should I use Dependency Injection and when should I use an Event-driven approach? It is not always easy to answer this question, but one simple rule that may help you is to use Dependency Injection for all your main activities within the application, and Event-driven approach for all secondary actions. For example, use Dependency Injection with things like creating an order within the OrderService class with IOrderRepository, and delegate sending of email, something that is not a crucial part of the main order creation flow, to some event handler.
The same principle scales up beyond a single application. A microservice, at its best, is a deployable unit with one reason to change; the classic distributed-monolith anti-pattern happens precisely when teams cut services along technical layers (a “database service,” a “controllers service”) instead of along responsibilities. Serverless functions push this further still: a well-designed Lambda or Azure Function does one thing in response to one event, and the runtime almost forces SRP on you. The shape of the code changes, but the principle does not.
Conclusion
We started off with a very heavy controller, just one class, and ended up with an elaborate collection of classes. The advantages of these changes are quite apparent from the examples. However, there are still many ways to improve these examples. For example, OrderService.Create method can be moved to a class of its own: OrderCreator. Since order creation is an independent unit of business logic following Single Responsibility Principle, it is only natural for it to have its own class with its own set of dependencies. Likewise, order removal and order cancellation can each be implemented in their own classes.
When I wrote highly coupled code, something similar to the very first example in this article, any small change to requirement could easily lead to many changes in other parts of code. SRP helps developers write code that are decoupled, where each class has its own job. If the specifications of this job change, the developer makes changes to that specific class only. The change is less likely to break the entire application as other classes should still be doing their job as before, unless of course they were broken in the first place.
Developing code upfront using these techniques and following Single Responsibility Principle can seem like a daunting task, but the efforts will certainly pay off as the project grows and the development continues.
Further Reading on the Toptal Blog:
Adel Fayzrakhmanov
Kazan, Tatarstan, Russia
Member since October 19, 2015
About the author
Adel (MCE) has 15+ years in software development, focused on web technology and quality architecture. PHP and .NET are his forté.

