Loading learning content...
You understand class extraction. You know how to split responsibilities. You can measure and maintain cohesion. Now it's time to put it all together.
This page walks through a complete, real-world SRP refactoring from start to finish. We'll take a messy, SRP-violating class through every stage of analysis, extraction, splitting, and verification—demonstrating the full process a professional engineer uses when confronting poorly designed code.
This isn't a simplified example. It mirrors the complexity you'll encounter in production systems. Follow along, and you'll have a template for tackling any SRP violation you encounter.
By the end of this page, you will have a complete, repeatable process for SRP refactoring. You'll understand how to analyze code for violations, plan your refactoring strategy, execute it safely, test the results, and document your changes for team understanding.
Let's examine a realistic SRP-violating class—a ReportGenerator from a financial services application. This class has grown over time to handle far more than report generation.
The Problem Class:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108
public class ReportGenerator { // Data access dependencies private final TransactionRepository transactionRepo; private final AccountRepository accountRepo; private final UserRepository userRepo; // Report configuration private final ReportTemplates templates; private final DateFormatter dateFormatter; // Export functionality private final PdfRenderer pdfRenderer; private final ExcelExporter excelExporter; private final CsvFormatter csvFormatter; // Delivery mechanism private final EmailService emailService; private final FileStorage storage; // Scheduling private final Scheduler scheduler; private final CronParser cronParser; // Audit and compliance private final AuditLogger auditLogger; private final ComplianceChecker compliance; // Constructor with 14 dependencies! Clear smell public ReportGenerator( TransactionRepository transactionRepo, AccountRepository accountRepo, UserRepository userRepo, ReportTemplates templates, DateFormatter dateFormatter, PdfRenderer pdfRenderer, ExcelExporter excelExporter, CsvFormatter csvFormatter, EmailService emailService, FileStorage storage, Scheduler scheduler, CronParser cronParser, AuditLogger auditLogger, ComplianceChecker compliance ) { // ... initialize all dependencies } // ==== Data Gathering ==== public ReportData gatherTransactionData(DateRange range, List<AccountId> accounts) { List<Transaction> transactions = transactionRepo.findByDateAndAccounts(range, accounts); Map<AccountId, Account> accountDetails = accountRepo.findByIds(accounts); return new ReportData(transactions, accountDetails); } public ReportData gatherUserActivityData(DateRange range, UserId userId) { User user = userRepo.findById(userId); List<Transaction> transactions = transactionRepo.findByUser(userId, range); return new ReportData(user, transactions); } // ==== Report Generation ==== public Report generateTransactionReport(ReportData data, ReportFormat format) { String content = templates.render("transaction-report", data); content = dateFormatter.formatDatesIn(content); return new Report(content, format); } public Report generateComplianceReport(ReportData data) { compliance.validateDataCompleteness(data); String content = templates.render("compliance-report", data); return new Report(content, ReportFormat.PDF); } // ==== Export/Rendering ==== public byte[] renderToPdf(Report report) { return pdfRenderer.render(report.getContent()); } public byte[] exportToExcel(Report report) { return excelExporter.export(report.getContent()); } public String exportToCsv(Report report) { return csvFormatter.format(report.getContent()); } // ==== Delivery ==== public void emailReport(Report report, List<String> recipients) { byte[] attachment = renderToPdf(report); emailService.sendWithAttachment(recipients, "Your Report", attachment); auditLogger.log("Report emailed to " + recipients); } public String saveToStorage(Report report, String filename) { byte[] content = renderToPdf(report); String path = storage.save(filename, content); auditLogger.log("Report saved to " + path); return path; } // ==== Scheduling ==== public void scheduleReport(String cronExpression, ReportConfig config) { CronSchedule schedule = cronParser.parse(cronExpression); scheduler.schedule(schedule, () -> executeScheduledReport(config)); } private void executeScheduledReport(ReportConfig config) { ReportData data = gatherTransactionData(config.getDateRange(), config.getAccounts()); Report report = generateTransactionReport(data, config.getFormat()); emailReport(report, config.getRecipients()); } // ==== Audit ==== public void logReportAccess(ReportId id, UserId accessingUser) { auditLogger.log("Report " + id + " accessed by " + accessingUser); }}Immediate observations:
Before touching any code, we analyze and document the responsibilities present in the class. This mapping guides all subsequent decisions.
Step 1.1: List all methods and their primary purpose
| Method | Purpose | Dependencies Used | Actor/Stakeholder |
|---|---|---|---|
gatherTransactionData() | Fetch transaction data | transactionRepo, accountRepo | Data Team |
gatherUserActivityData() | Fetch user activity | userRepo, transactionRepo | Data Team |
generateTransactionReport() | Create report content | templates, dateFormatter | Business/Product |
generateComplianceReport() | Create compliance report | templates, compliance | Compliance Team |
renderToPdf() | Convert to PDF | pdfRenderer | Engineering |
exportToExcel() | Convert to Excel | excelExporter | Engineering |
exportToCsv() | Convert to CSV | csvFormatter | Engineering |
emailReport() | Send via email | emailService, auditLogger | Ops/Marketing |
saveToStorage() | Persist to storage | storage, auditLogger | Ops |
scheduleReport() | Set up recurring run | scheduler, cronParser | Ops Team |
logReportAccess() | Track access | auditLogger | Compliance Team |
Step 1.2: Identify responsibility clusters
Grouping by actor and change reason reveals distinct responsibilities:
This mapping document becomes your refactoring guide. Share it with your team for review before making changes. Getting alignment on the responsibility split upfront prevents costly rework later.
With responsibilities identified, plan the target architecture. This is where we decide how many classes to create and what each will contain.
Step 2.1: Design the target class structure
123456789101112131415161718192021222324252627282930313233343536
Target Class Structure: 1. ReportDataGatherer - gatherTransactionData() - gatherUserActivityData() Dependencies: transactionRepo, accountRepo, userRepo 2. ReportContentGenerator - generateTransactionReport() - generateComplianceReport() Dependencies: templates, dateFormatter, compliance 3. ReportExporter - renderToPdf() - exportToExcel() - exportToCsv() Dependencies: pdfRenderer, excelExporter, csvFormatter 4. ReportDeliveryService - emailReport() - saveToStorage() Dependencies: emailService, storage, ReportExporter (for PDF) 5. ReportScheduler - scheduleReport() - executeScheduledReport() Dependencies: scheduler, cronParser, orchestrates others 6. ReportAuditService - logReportAccess() - logDelivery() (extracted from email/save methods) Dependencies: auditLogger 7. ReportOrchestrator (facade for common workflows) - generateAndDeliverReport() Dependencies: coordinates above servicesStep 2.2: Identify dependencies between new classes
Drawing a dependency diagram reveals the relationships:
ReportOrchestrator
├── ReportDataGatherer
├── ReportContentGenerator
├── ReportExporter
├── ReportDeliveryService ─── ReportExporter
│ └── ReportAuditService
├── ReportScheduler ─── (coordinates all via Orchestrator)
└── ReportAuditService
Step 2.3: Plan the extraction order
Extract in dependency order—leaves first, coordinators last:
Steps 1-4 have no inter-dependencies and could be extracted in parallel by different team members. This is a benefit of planning before coding—you can parallelize the work safely.
Now we execute the plan, one class at a time. For each extraction:
Extraction 1: ReportAuditService
1234567891011121314151617181920212223242526
/** * Handles all audit logging for report operations. * * Responsibility: Recording report access and delivery * for compliance purposes. * Change Reason: Compliance requirements change. */public class ReportAuditService { private final AuditLogger auditLogger; public ReportAuditService(AuditLogger auditLogger) { this.auditLogger = auditLogger; } public void logReportAccess(ReportId id, UserId accessingUser) { auditLogger.log("Report " + id + " accessed by " + accessingUser); } public void logReportDelivered(String destination, String method) { auditLogger.log("Report delivered to " + destination + " via " + method); } public void logReportGenerated(String reportType, DateRange range) { auditLogger.log("Report generated: " + reportType + " for " + range); }}Extraction 2: ReportExporter
1234567891011121314151617181920212223242526272829303132333435363738394041
/** * Converts Report content to various output formats. * * Responsibility: Format conversion and rendering. * Change Reason: New format support, renderer library updates. */public class ReportExporter { private final PdfRenderer pdfRenderer; private final ExcelExporter excelExporter; private final CsvFormatter csvFormatter; public ReportExporter( PdfRenderer pdfRenderer, ExcelExporter excelExporter, CsvFormatter csvFormatter ) { this.pdfRenderer = pdfRenderer; this.excelExporter = excelExporter; this.csvFormatter = csvFormatter; } public byte[] toPdf(Report report) { return pdfRenderer.render(report.getContent()); } public byte[] toExcel(Report report) { return excelExporter.export(report.getContent()); } public String toCsv(Report report) { return csvFormatter.format(report.getContent()); } public byte[] export(Report report, ExportFormat format) { return switch (format) { case PDF -> toPdf(report); case EXCEL -> toExcel(report); case CSV -> toCsv(report).getBytes(); }; }}Extraction 3: ReportDataGatherer
123456789101112131415161718192021222324252627282930313233
/** * Assembles data required for report generation. * * Responsibility: Data retrieval and aggregation. * Change Reason: Data model changes, new data sources. */public class ReportDataGatherer { private final TransactionRepository transactionRepo; private final AccountRepository accountRepo; private final UserRepository userRepo; public ReportDataGatherer( TransactionRepository transactionRepo, AccountRepository accountRepo, UserRepository userRepo ) { this.transactionRepo = transactionRepo; this.accountRepo = accountRepo; this.userRepo = userRepo; } public ReportData gatherTransactionData(DateRange range, List<AccountId> accounts) { List<Transaction> transactions = transactionRepo.findByDateAndAccounts(range, accounts); Map<AccountId, Account> accountDetails = accountRepo.findByIds(accounts); return new ReportData(transactions, accountDetails); } public ReportData gatherUserActivityData(DateRange range, UserId userId) { User user = userRepo.findById(userId); List<Transaction> transactions = transactionRepo.findByUser(userId, range); return new ReportData(user, transactions); }}Extraction 4-5: ReportContentGenerator and ReportDeliveryService
(Following the same pattern—create class, move methods, test, commit)
123456789101112131415161718192021222324252627282930313233
/** * Creates report content from data and templates. * * Responsibility: Content generation and formatting. * Change Reason: Report format requirements, template changes. */public class ReportContentGenerator { private final ReportTemplates templates; private final DateFormatter dateFormatter; private final ComplianceChecker compliance; public ReportContentGenerator( ReportTemplates templates, DateFormatter dateFormatter, ComplianceChecker compliance ) { this.templates = templates; this.dateFormatter = dateFormatter; this.compliance = compliance; } public Report generateTransactionReport(ReportData data, ReportFormat format) { String content = templates.render("transaction-report", data); content = dateFormatter.formatDatesIn(content); return new Report(content, format); } public Report generateComplianceReport(ReportData data) { compliance.validateDataCompleteness(data); String content = templates.render("compliance-report", data); return new Report(content, ReportFormat.PDF); }}12345678910111213141516171819202122232425262728293031323334353637
/** * Handles report delivery via various channels. * * Responsibility: Report transmission and storage. * Change Reason: Delivery channel changes, new destinations. */public class ReportDeliveryService { private final EmailService emailService; private final FileStorage storage; private final ReportExporter exporter; private final ReportAuditService auditService; public ReportDeliveryService( EmailService emailService, FileStorage storage, ReportExporter exporter, ReportAuditService auditService ) { this.emailService = emailService; this.storage = storage; this.exporter = exporter; this.auditService = auditService; } public void emailReport(Report report, List<String> recipients) { byte[] attachment = exporter.toPdf(report); emailService.sendWithAttachment(recipients, "Your Report", attachment); auditService.logReportDelivered(String.join(", ", recipients), "email"); } public String saveToStorage(Report report, String filename) { byte[] content = exporter.toPdf(report); String path = storage.save(filename, content); auditService.logReportDelivered(path, "storage"); return path; }}Make a separate commit after each successful extraction. This creates a clear history and allows easy rollback if later steps reveal problems. Don't batch all extractions into one massive commit.
With leaf classes extracted, we create the orchestrator that coordinates common workflows. This becomes the public API that replaces the original God class.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687
/** * Orchestrates complete report workflows. * * Responsibility: Coordinating report generation pipelines. * This is the primary entry point for client code. * * Note: This is a Facade that simplifies the subsystem. * For fine-grained control, clients can use individual services. */public class ReportOrchestrator { private final ReportDataGatherer dataGatherer; private final ReportContentGenerator contentGenerator; private final ReportExporter exporter; private final ReportDeliveryService deliveryService; private final ReportAuditService auditService; public ReportOrchestrator( ReportDataGatherer dataGatherer, ReportContentGenerator contentGenerator, ReportExporter exporter, ReportDeliveryService deliveryService, ReportAuditService auditService ) { this.dataGatherer = dataGatherer; this.contentGenerator = contentGenerator; this.exporter = exporter; this.deliveryService = deliveryService; this.auditService = auditService; } /** * Complete workflow: gather data, generate report, deliver via email. */ public void generateAndEmailReport(ReportRequest request) { // 1. Gather data ReportData data = dataGatherer.gatherTransactionData( request.getDateRange(), request.getAccounts() ); // 2. Generate content Report report = contentGenerator.generateTransactionReport( data, request.getFormat() ); auditService.logReportGenerated("transaction", request.getDateRange()); // 3. Deliver deliveryService.emailReport(report, request.getRecipients()); } /** * Complete workflow: gather data, generate report, save to storage. */ public String generateAndSaveReport(ReportRequest request, String filename) { ReportData data = dataGatherer.gatherTransactionData( request.getDateRange(), request.getAccounts() ); Report report = contentGenerator.generateTransactionReport( data, request.getFormat() ); auditService.logReportGenerated("transaction", request.getDateRange()); return deliveryService.saveToStorage(report, filename); } /** * Just generate a report without delivery (for preview/download). */ public byte[] generateReport(ReportRequest request, ExportFormat exportFormat) { ReportData data = dataGatherer.gatherTransactionData( request.getDateRange(), request.getAccounts() ); Report report = contentGenerator.generateTransactionReport( data, request.getFormat() ); auditService.logReportGenerated("transaction", request.getDateRange()); return exporter.export(report, exportFormat); }}The Scheduler uses the Orchestrator:
1234567891011121314151617181920212223242526272829303132
/** * Manages scheduled/recurring report generation. * * Responsibility: Scheduling and triggering reports. * Change Reason: Scheduling requirements, cron format changes. */public class ReportScheduler { private final Scheduler scheduler; private final CronParser cronParser; private final ReportOrchestrator orchestrator; public ReportScheduler( Scheduler scheduler, CronParser cronParser, ReportOrchestrator orchestrator ) { this.scheduler = scheduler; this.cronParser = cronParser; this.orchestrator = orchestrator; } public ScheduledJobId scheduleReport(String cronExpression, ReportRequest request) { CronSchedule schedule = cronParser.parse(cronExpression); return scheduler.schedule(schedule, () -> { orchestrator.generateAndEmailReport(request); }); } public void cancelScheduledReport(ScheduledJobId jobId) { scheduler.cancel(jobId); }}The ReportOrchestrator is a Facade—it provides a simplified interface to a complex subsystem. Clients who need simple operations use the Orchestrator. Clients who need fine-grained control can use individual services directly.
With the new structure in place, update all code that used the original ReportGenerator. This is where the benefits of the refactoring become visible—client code becomes clearer and more focused.
123456789101112131415161718192021222324252627282930313233
// Before: Client uses God classpublic class ReportController { private final ReportGenerator generator; @PostMapping("/reports/generate") public ResponseEntity<byte[]> generate( @RequestBody ReportRequest request ) { // Client must know the // multi-step workflow ReportData data = generator .gatherTransactionData( request.getDateRange(), request.getAccounts() ); Report report = generator .generateTransactionReport( data, request.getFormat() ); byte[] pdf = generator .renderToPdf(report); generator.logReportAccess( report.getId(), getCurrentUser() ); return ResponseEntity.ok(pdf); }}12345678910111213141516171819202122232425
// After: Client uses Orchestratorpublic class ReportController { private final ReportOrchestrator orchestrator; @PostMapping("/reports/generate") public ResponseEntity<byte[]> generate( @RequestBody ReportRequest request ) { // Single call - orchestrator // handles the workflow byte[] pdf = orchestrator .generateReport( request, ExportFormat.PDF ); return ResponseEntity.ok(pdf); }} // Client code is dramatically// simpler and more focused.// The workflow complexity is// hidden in the orchestrator.Migration Strategy Options:
Big Bang — Update all clients at once. Best for small codebases with good test coverage.
Strangler Fig — Keep old class as a facade that delegates to new classes. Gradually migrate clients.
Parallel Running — Run both implementations during transition, comparing results.
For this example, we'll use the Strangler Fig approach:
1234567891011121314151617181920212223242526272829303132
/** * @deprecated Use ReportOrchestrator or specific services instead. * * This class is maintained for backward compatibility during migration. * All methods delegate to the new refactored services. */@Deprecatedpublic class ReportGenerator { private final ReportOrchestrator orchestrator; private final ReportDataGatherer dataGatherer; private final ReportExporter exporter; private final ReportAuditService auditService; // Constructor wires up new services public ReportGenerator(ReportOrchestrator orchestrator, /* ... */) { this.orchestrator = orchestrator; // ... } // Old methods delegate to new services @Deprecated public ReportData gatherTransactionData(DateRange range, List<AccountId> accounts) { return dataGatherer.gatherTransactionData(range, accounts); } @Deprecated public byte[] renderToPdf(Report report) { return exporter.toPdf(report); } // ... other deprecated methods delegating to new classes}Mark the old class @Deprecated with a clear migration path. Set a removal date. Track usage. This gives teams time to migrate while preventing new usage of the old API.
Testing verifies that the refactoring preserved behavior while improving structure. We need both unit tests for individual classes and integration tests for the complete workflows.
Unit Testing Each Extracted Class:
12345678910111213141516171819202122232425262728293031323334353637383940414243
class ReportExporterTest { // Dependencies are easily mocked now - high cohesion = easy testing private PdfRenderer mockPdfRenderer; private ExcelExporter mockExcelExporter; private CsvFormatter mockCsvFormatter; private ReportExporter exporter; @BeforeEach void setup() { mockPdfRenderer = mock(PdfRenderer.class); mockExcelExporter = mock(ExcelExporter.class); mockCsvFormatter = mock(CsvFormatter.class); exporter = new ReportExporter( mockPdfRenderer, mockExcelExporter, mockCsvFormatter ); } @Test void toPdf_delegatesToPdfRenderer() { Report report = new Report("content", ReportFormat.PDF); byte[] expected = "pdf bytes".getBytes(); when(mockPdfRenderer.render("content")).thenReturn(expected); byte[] result = exporter.toPdf(report); assertThat(result).isEqualTo(expected); verify(mockPdfRenderer).render("content"); } @Test void export_selectsCorrectExporter() { Report report = new Report("content", ReportFormat.EXCEL); byte[] expected = "excel bytes".getBytes(); when(mockExcelExporter.export("content")).thenReturn(expected); byte[] result = exporter.export(report, ExportFormat.EXCEL); assertThat(result).isEqualTo(expected); }}Integration Testing the Orchestrator:
12345678910111213141516171819202122232425262728293031323334353637383940
@SpringBootTestclass ReportOrchestratorIntegrationTest { @Autowired private ReportOrchestrator orchestrator; @MockBean private EmailService mockEmailService; // External dependency mocked @Test void generateAndEmailReport_completesFullWorkflow() { // Arrange ReportRequest request = ReportRequest.builder() .dateRange(DateRange.lastMonth()) .accounts(List.of(testAccount)) .format(ReportFormat.PDF) .recipients(List.of("test@example.com")) .build(); // Act orchestrator.generateAndEmailReport(request); // Assert - verify complete workflow executed verify(mockEmailService).sendWithAttachment( eq(List.of("test@example.com")), eq("Your Report"), any(byte[].class) // PDF bytes ); } @Test void generateReport_matchesPreviousBehavior() { // Compare output to known-good baseline ReportRequest request = standardTestRequest(); byte[] result = orchestrator.generateReport(request, ExportFormat.PDF); // Golden file comparison ensures refactoring didn't change output assertThat(result).isEqualTo(loadGoldenFile("expected_report.pdf")); }}Notice how much easier testing is now. The original 14-dependency class required complex setup. Each extracted class has 2-4 dependencies. Unit tests are focused and fast. This testability improvement is itself evidence that the refactoring was worthwhile.
The final phase verifies the refactoring achieved its goals and documents the new architecture for team understanding.
Verification Checklist:
| Metric | Before (ReportGenerator) | After (Refactored) |
|---|---|---|
| Classes | 1 God class | 7 focused classes |
| Dependencies per class | 14 | 2-5 |
| Methods per class | 11 | 2-5 |
| Lines of code per class | ~200 | ~40-60 |
| Test setup complexity | High (14 mocks) | Low (2-4 mocks) |
| Reason to change | ~6 different reasons | 1 reason per class |
| Teams that can own it | Nobody (too broad) | Clear ownership possible |
Documentation: Architecture Decision Record (ADR)
Document the refactoring decision and new architecture:
1234567891011121314151617181920212223242526272829303132333435
# ADR-003: ReportGenerator Refactoring ## StatusAccepted ## ContextThe ReportGenerator class had grown to 14 dependencies and 11 methods spanning 6 distinct responsibilities. Testing was difficult, and changesfrequently caused regressions in unrelated functionality. ## DecisionSplit ReportGenerator into 7 focused classes:- ReportDataGatherer - data retrieval- ReportContentGenerator - content creation - ReportExporter - format conversion- ReportDeliveryService - transmission- ReportScheduler - recurring execution- ReportAuditService - compliance logging- ReportOrchestrator - workflow coordination (primary API) ## Consequences**Positive:**- Each class has single responsibility- Testing complexity reduced significantly- Clear team ownership possible- Changes localized to affected class **Negative:**- More files to navigate- Initial learning curve for team- Existing clients need migration ## Migration PlanStrangler Fig pattern with deprecated wrapper.Full migration target: Q2 2024.The God class has been systematically decomposed into focused, single-responsibility classes. The codebase is more maintainable, testable, and ownable. This pattern—analyze, plan, extract, orchestrate, migrate, verify—applies to any SRP refactoring.
We've walked through a complete, real-world SRP refactoring—from a 14-dependency God class to 7 focused, cohesive classes. This process is repeatable and scalable to any SRP violation you encounter.
Module Complete: Refactoring for SRP
You now have the complete toolkit for SRP refactoring:
These skills separate developers who merely recognize problems from engineers who systematically solve them. Apply this methodology to the God classes in your codebase, and you'll transform legacy messes into maintainable systems.
Congratulations! You've completed the Refactoring for SRP module. You now possess the skills to identify SRP violations, plan systematic refactorings, execute them safely, and verify the results. The next chapter will explore the Open/Closed Principle—designing systems that can be extended without modification.