Loading content...
If the 'one method per class' misconception is SRP's most obvious misinterpretation, the 'tiny classes everywhere' fallacy is its most insidious cousin. This belief manifests as a relentless drive to break every class into smaller pieces—regardless of whether such decomposition serves any design goal.
Developers infected with this thinking treat class size as a metric in itself. They view any class over 100 lines with suspicion, any class over 200 with alarm, and any class over 500 as an unforgivable sin. The result is codebases where meaningful abstractions are shattered into dozens of collaborating micro-classes, each doing too little to be comprehensible in isolation.
This isn't principled design—it's Tetris anxiety applied to software architecture.
Class explosion doesn't just make code harder to navigate—it shifts complexity from 'within classes' to 'between classes.' You trade local complexity (large, rich classes) for distributed complexity (intricate webs of tiny collaborators). The second is far harder to debug, test, and evolve.
By the end of this page, you will understand why class size is not a valid proxy for SRP compliance. You'll learn to recognize the symptoms of over-decomposition and discover strategies for finding the right level of class granularity.
The fundamental error in 'tiny classes everywhere' thinking is conflating size (lines of code, method count) with responsibility (reason to change). These concepts are orthogonal—a class can be large with one responsibility or small with multiple responsibilities.
A thought experiment:
Consider two classes:
Which violates SRP? Class A—despite being 10x smaller. Its two responsibilities (authentication vs. logging) serve different stakeholders and change for different reasons.
Class B, though large, might have perfect SRP compliance if all 500 lines serve the single purpose of 'sorting data efficiently' and would only change when sorting requirements change.
| Class Type | Size | Responsibility Count | SRP Compliance |
|---|---|---|---|
| Focused Domain Entity (e.g., Invoice) | Large (300+ lines) | One (invoice management) | ✅ Compliant |
| God Class (e.g., UserManager) | Large (500+ lines) | Many (auth, profile, billing) | ❌ Violation |
| Micro-class (e.g., EmailValidator) | Small (20 lines) | One (validation) | ✅ Compliant |
| Hidden Multi-tasker | Small (50 lines) | Two (format + persist) | ❌ Violation |
The correct measure:
SRP compliance is measured by:
None of these correlate directly with line count. A 500-line class with perfect cohesion is superior to five 100-line classes with tangled dependencies.
When code review feedback says 'this class is too big,' the appropriate response isn't automatic splitting—it's analysis. Ask: 'Is it big because it has multiple responsibilities, or because one responsibility is genuinely complex?' Only the former warrants decomposition.
Teams that aggressively fragment their classes pay hidden costs that accumulate over time. Understanding these costs helps resist the siren call of endless decomposition.
OrderProcessor, OrderHandler, OrderManager, OrderService—what's the difference?OrderValidator, OrderPersister, OrderNotifier, losing its identity.The coupling transfer:
When you split a large class into many small ones, you don't eliminate complexity—you transfer it. Internal complexity (within a class) becomes external complexity (between classes).
Consider what happens when you fragment a ReportGenerator into DataFetcher, DataTransformer, ReportFormatter, ReportRenderer, and ReportExporter:
You've traded one 300-line class you could understand for five 60-line classes that only make sense together.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748
// ❌ Over-decomposed: 5 classes, high inter-class couplingpublic class ReportOrchestrator { private final DataFetcher dataFetcher; private final DataTransformer transformer; private final ReportFormatter formatter; private final ReportRenderer renderer; private final ReportExporter exporter; public void generateReport(ReportRequest request) { RawData data = dataFetcher.fetch(request.getDataSource()); TransformedData transformed = transformer.transform(data, request.getRules()); FormattedReport formatted = formatter.format(transformed, request.getTemplate()); RenderedReport rendered = renderer.render(formatted); exporter.export(rendered, request.getDestination()); }} // Each class knows about adjacent classes. // Change one, potentially change all.// The "report" concept lives nowhere and everywhere. // ✅ Cohesive: One class with clear internal organizationpublic class ReportGenerator { private final DataSource dataSource; private final TemplateEngine templateEngine; public Report generate(ReportRequest request) { RawData data = fetchData(request.getDataSource()); ReportData reportData = transformData(data, request.getRules()); return buildReport(reportData, request.getTemplate()); } public void export(Report report, Destination destination) { String rendered = render(report); write(rendered, destination); } // Private methods organize internal complexity private RawData fetchData(DataSourceSpec spec) { ... } private ReportData transformData(RawData data, Rules rules) { ... } private Report buildReport(ReportData data, Template template) { ... } private String render(Report report) { ... } private void write(String content, Destination destination) { ... }} // "Report generation" lives in one place.// Internal methods can change freely without affecting other classes.// The concept is coherent and navigable.Complexity doesn't vanish when you split classes—it transforms. Internal complexity (private methods, internal state) becomes external complexity (dependencies, interfaces, coordination). Choose which form of complexity is more manageable for your specific situation.
Criticizing 'tiny classes everywhere' doesn't mean endorsing monolithic god classes. Small classes absolutely have their place—but that place is defined by design principles, not line count goals.
Legitimate reasons for small, focused classes:
The key distinction:
Good decomposition is pull-based — forces in your system pull code apart. Bad decomposition is push-based — artificial rules push code apart.
| Pull-Based (Good) | Push-Based (Bad) |
|---|---|
| 'Marketing and Finance need different reports' | 'Classes should be under 100 lines' |
| 'We need to mock the database in tests' | 'Each method deserves its own class' |
| 'Sorting algorithms vary by data type' | 'Let's split this because it feels big' |
| 'Notification logic is shared by 3 services' | 'Uncle Bob says single responsibility' |
Pull-based decomposition responds to real design pressures. Push-based decomposition responds to arbitrary metrics.
Before splitting a class, identify the design pressure forcing the split. If you can't name a concrete scenario (different stakeholders, testing difficulty, reuse need, deployment boundary), the split is probably premature and will add complexity without benefit.
The optimal class size isn't a fixed number—it's a context-dependent balance between competing concerns. Several factors influence where your classes should land on the size spectrum.
Factors favoring larger classes:
The 'reason to read' heuristic:
A practical test: Can a developer understand this class by reading it top-to-bottom in one sitting, building a coherent mental model?
A 500-line PaymentProcessor that clearly progresses through validation, authorization, settlement, and reconciliation can be readable. A 200-line class that jumps between user preferences and API throttling cannot.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455
// A well-sized class has a clear narrative public class OrderFulfillment { // The class tells a story. Each section flows to the next. // === Section 1: Order Intake === public FulfillmentResult fulfill(Order order) { validate(order); Order reserved = reserveInventory(order); PaymentConfirmation payment = processPayment(reserved); ShipmentPlan shipment = planShipment(reserved, payment); return createResult(reserved, payment, shipment); } // === Section 2: Validation === private void validate(Order order) { validateItems(order); validateShippingAddress(order); validatePaymentMethod(order); } private void validateItems(Order order) { ... } private void validateShippingAddress(Order order) { ... } private void validatePaymentMethod(Order order) { ... } // === Section 3: Inventory === private Order reserveInventory(Order order) { for (LineItem item : order.getItems()) { reserveItem(item); } return order.withReservations(getReservations()); } private void reserveItem(LineItem item) { ... } private List<Reservation> getReservations() { ... } // === Section 4: Payment === private PaymentConfirmation processPayment(Order order) { ... } // === Section 5: Shipment === private ShipmentPlan planShipment(Order order, PaymentConfirmation payment) { ... } // === Section 6: Result Building === private FulfillmentResult createResult( Order order, PaymentConfirmation payment, ShipmentPlan shipment ) { ... }} // This might be 300+ lines, but the *narrative is coherent*.// A developer can read it and understand order fulfillment.// Splitting into FulfillmentValidator, InventoryReserver, PaymentProcessor, // ShipmentPlanner would scatter the story across 5 files.Here's a powerful heuristic: Refactoring toward smaller classes should meet resistance. If splitting a class is easy and obvious, you're probably identifying a genuine responsibility boundary. If it's awkward and forces artificial decisions, the class might be correctly unified.
Resistance signals:
The extraction test:
When considering a split, attempt the extraction mentally (or on a branch). Ask:
XxxHelper, XxxUtils, XxxProcessor?If you answer 'yes' to all four, the split is probably sound. If you struggle, the original class may embody a genuinely unified concept.
When extraction feels forced—requiring bidirectional dependencies, passing extensive context, or creating leaky abstractions—trust that feeling. The resistance is the code telling you the current structure might actually be appropriate. Not all large classes are god classes.
Let's examine a realistic case: an Order entity in an e-commerce system. We'll compare the 'tiny classes' approach with a cohesive approach and see the tradeoffs.
The over-decomposed version:
123456789101112131415161718192021222324252627282930313233343536373839404142
// ❌ Over-decomposed: 8 classes for one domain concept public class Order { private OrderId id; private List<LineItem> items; // Just data, no behavior - an anemic model} public class OrderValidator { public ValidationResult validate(Order order) { ... }} public class OrderPricingCalculator { public Money calculateTotal(Order order) { ... }} public class OrderTaxCalculator { public Money calculateTax(Order order) { ... }} public class OrderDiscountApplier { public Order applyDiscounts(Order order, List<Coupon> coupons) { ... }} public class OrderStatusManager { public void transition(Order order, OrderStatus newStatus) { ... }} public class OrderShippingCalculator { public ShippingOptions calculateShipping(Order order) { ... }} public class OrderInventoryChecker { public AvailabilityResult checkAvailability(Order order) { ... }} // Problems:// 1. "Order" as a concept is scattered across 8 files// 2. Order class is anemic - just a data bag// 3. Business rules live in services, not the entity// 4. To understand orders, you must trace 8 classes// 5. Changing order logic requires coordinating multiple filesThe cohesive version:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151
// ✅ Cohesive: One rich domain entity (might be 400+ lines) public class Order { private final OrderId id; private final CustomerId customerId; private final List<LineItem> items; private final ShippingAddress shippingAddress; private OrderStatus status; private List<AppliedDiscount> discounts; private Money taxAmount; private LocalDateTime createdAt; private LocalDateTime lastModifiedAt; // === Factory Method === public static Order create(CustomerId customerId, ShippingAddress address) { Order order = new Order(OrderId.generate(), customerId, address); order.validate(); return order; } // === Line Item Management === public void addItem(Product product, int quantity) { validateEditable(); LineItem item = LineItem.create(product, quantity); items.add(item); recalculateTotals(); touch(); } public void removeItem(LineItemId itemId) { validateEditable(); items.removeIf(item -> item.getId().equals(itemId)); recalculateTotals(); touch(); } public void updateQuantity(LineItemId itemId, int newQuantity) { validateEditable(); findItem(itemId).updateQuantity(newQuantity); recalculateTotals(); touch(); } // === Discount Handling === public void applyDiscount(Discount discount) { validateEditable(); if (!discount.isApplicableTo(this)) { throw new InvalidDiscountException(discount); } discounts.add(discount.applyTo(this)); recalculateTotals(); touch(); } // === Pricing === public Money getSubtotal() { return items.stream() .map(LineItem::getLineTotal) .reduce(Money.ZERO, Money::add); } public Money getDiscountAmount() { return discounts.stream() .map(AppliedDiscount::getAmount) .reduce(Money.ZERO, Money::add); } public Money getTaxAmount() { return taxAmount; } public Money getTotal() { return getSubtotal() .subtract(getDiscountAmount()) .add(getTaxAmount()); } // === Lifecycle === public void submit() { validateForSubmission(); this.status = OrderStatus.SUBMITTED; touch(); } public void confirm() { validateTransition(OrderStatus.CONFIRMED); this.status = OrderStatus.CONFIRMED; touch(); } public void ship(TrackingInfo trackingInfo) { validateTransition(OrderStatus.SHIPPED); this.status = OrderStatus.SHIPPED; // ... handle tracking touch(); } public void cancel(CancellationReason reason) { validateCancellable(); this.status = OrderStatus.CANCELLED; touch(); } // === Queries === public boolean isEditable() { return status == OrderStatus.DRAFT; } public boolean isCancellable() { return status.isBefore(OrderStatus.SHIPPED); } public int getItemCount() { return items.stream().mapToInt(LineItem::getQuantity).sum(); } // === Private Helpers === private void validateEditable() { if (!isEditable()) { throw new OrderNotEditableException(id, status); } } private void validateForSubmission() { if (items.isEmpty()) { throw new EmptyOrderException(id); } // ... additional validation } private void validateTransition(OrderStatus target) { if (!status.canTransitionTo(target)) { throw new InvalidTransitionException(id, status, target); } } private void recalculateTotals() { this.taxAmount = TaxCalculator.calculate(this); } private void touch() { this.lastModifiedAt = LocalDateTime.now(); } private LineItem findItem(LineItemId itemId) { return items.stream() .filter(item -> item.getId().equals(itemId)) .findFirst() .orElseThrow(() -> new LineItemNotFoundException(itemId)); }}Comparing the approaches:
| Aspect | Over-Decomposed | Cohesive |
|---|---|---|
| Files to understand 'Order' | 8 | 1 |
| Where business rules live | Scattered services | The entity itself |
| Encapsulation | Poor (exposed state) | Strong (controlled transitions) |
| Testability | Mock-heavy | Straightforward |
| Discoverability | Hunt through packages | Navigate one file |
| Domain alignment | Anemic model | Rich domain model |
The cohesive Order might be 400 lines, but it represents one domain concept completely. An engineer reading it understands orders. The over-decomposed version requires assembling knowledge from 8 files.
In Domain-Driven Design, entities should encapsulate their behavior, not just their data. A rich Order entity that manages its own lifecycle, pricing, and validation honors both SRP and DDD principles. One responsibility: being a complete, valid Order.
We've examined why 'tiny classes everywhere' is a misapplication of SRP. Here are the key insights:
What's next:
We've now debunked the two most common SRP misconceptions: 'one method per class' and 'tiny classes everywhere.' Next, we'll explore how to balance SRP with pragmatism—recognizing that principles exist to serve working software, not the reverse.
You now understand that class size is not a valid measure of SRP compliance. Focus on cohesion, stakeholder alignment, and change drivers—not line counts. Large cohesive classes often serve design better than scattered micro-classes.